03 镜像构建与优化
你写了一个Dockerfile,构建出来的镜像有800MB。同事的同样功能只有200MB。差距在哪里?在于对镜像分层和构建缓存的理解。
搞懂这两个机制,你就能写出构建快、体积小、安全性高的镜像。
一、镜像分层机制
镜像不是一个整体文件,而是由多个层(layer)叠加而成的。每一条Dockerfile指令会生成一个新的层。
来看一个例子:
FROM ubuntu:24.04 # 第1层:基础Ubuntu系统
RUN apt-get update && apt-get install -y python3 # 第2层:安装Python
COPY requirements.txt ./ # 第3层:复制依赖文件
RUN pip install -r requirements.txt # 第4层:安装pip依赖
COPY src ./src # 第5层:复制源代码构建完成后,镜像长这样:
┌─────────────────────┐
│ 第5层:源代码 │ ← 最新变更
├─────────────────────┤
│ 第4层:pip依赖 │
├─────────────────────┤
│ 第3层:requirements │
├─────────────────────┤
│ 第2层:Python运行时 │
├─────────────────────┤
│ 第1层:Ubuntu系统 │ ← 基础层
└─────────────────────┘每一层都是只读的,一旦创建就不可修改。层与层之间是叠加关系,上层的文件会覆盖下层同名文件。
1.1 查看镜像层
docker image history python:3.12-alpine输出会列出每一层的创建命令和大小。用--no-trunc可以看到完整的命令。
1.2 层的复用
层是按内容寻址的——相同内容的层只会存储一份。如果你有两个镜像都基于python:3.12-alpine,它们共享基础层,不需要重复存储。
这就是为什么Alpine基础镜像这么流行——它小,所有基于它的镜像都能省空间。
二、构建缓存
Docker构建镜像时会检查每一层是否有缓存。如果某一层的输入和之前构建时完全一致,Docker直接用缓存结果,跳过执行。
这就是为什么第二次构建比第一次快得多——大部分层都命中了缓存。
2.1 缓存失效规则
三种情况会导致缓存失效:
RUN指令的命令变了——Docker发现命令字符串不同,重新执行COPY或ADD的源文件变了——Docker检查文件内容和属性,有变化就重建- 前一层失效,后续层全部失效——层是链式依赖的,底层变了上层全部重建
第三点是关键。如果你在第一层改了东西,后面所有层都要重建,缓存全废。
2.2 优化Dockerfile利用缓存
来看一个反面教材:
FROM python:3.12-alpine
WORKDIR /app
COPY . . # 把所有文件复制进来
RUN pip install -r requirements.txt # 安装依赖
CMD ["python", "src/main.py"]问题在哪?COPY . .会复制所有文件,包括你的源代码。你每次改一行代码,COPY这层就失效,后面的pip install也要重装——即使requirements.txt根本没变。
优化后的写法:
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt ./ # 先只复制依赖文件
RUN pip install -r requirements.txt # 安装依赖
COPY . . # 再复制所有文件
CMD ["python", "src/main.py"]这样改代码时,COPY requirements.txt这层不变,pip install命中缓存,只需要重建最后一层COPY . .。
实测效果:
| 场景 | 优化前 | 优化后 |
|---|---|---|
| 首次构建 | 30秒 | 30秒 |
| 改了代码 | 30秒 | 2秒 |
| 改了依赖 | 30秒 | 30秒 |
改代码从30秒变2秒,这就是缓存的威力。
2.3 .dockerignore和缓存
.dockerignore也会影响缓存。如果你的构建上下文里有.git目录或node_modules,它们的变化会导致缓存失效。排除它们能让缓存更稳定。
三、多阶段构建
先来看一个问题。假设你要构建一个Java应用:
FROM eclipse-temurin:21-jdk-jammy
WORKDIR /app
COPY . .
RUN ./mvnw clean install
CMD ["java", "-jar", "target/app.jar"]构建出来的镜像有多大?880MB。因为JDK、Maven、构建工具全打包进去了。但运行时只需要JRE和那个jar包,编译器根本用不到。
多阶段构建就是为了解决这个问题:在一个Dockerfile里分多个阶段,编译在一个阶段,运行在另一个阶段,只把编译产物复制到最终镜像。
3.1 基本语法
# 第一阶段:构建
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /app
COPY . .
RUN ./mvnw clean install
# 第二阶段:运行
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=builder /app/target/*.jar ./app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]关键点:
- 第一阶段用
AS builder命名 - 第二阶段用
COPY --from=builder从第一阶段复制文件 - 最终镜像只包含第二阶段的内容
构建结果:
| 方式 | 镜像大小 |
|---|---|
| 单阶段 | 880MB |
| 多阶段 | 428MB |
体积直接减半。 因为最终镜像只有JRE和jar包,没有JDK和Maven。
3.2 Python项目的多阶段构建
Python项目也能用多阶段构建:
# 第一阶段:安装编译依赖
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# 第二阶段:运行
FROM python:3.12-alpine
WORKDIR /app
COPY --from=builder /install /usr/local
COPY src ./src
USER app
EXPOSE 8080
CMD ["python", "src/main.py"]第一阶段用完整的Python镜像来编译需要C编译器的包(比如numpy),第二阶段用Alpine镜像只包含运行时需要的文件。
3.3 只构建某个阶段
# 只构建builder阶段(调试用)
docker build --target builder -t my-app:debug .不加--target时,默认构建最后一个阶段。
四、镜像安全
4.1 不要用root运行
# 创建用户
RUN addgroup -S app && adduser -S app -G app
# 切换用户
USER app容器默认用root运行。如果容器被攻破,攻击者就有root权限。用非root用户能把风险限制在用户权限范围内。
4.2 只读文件系统
docker run --read-only my-app加上--read-only标志,容器的文件系统变成只读。应用需要写入的目录(如/tmp)用tmpfs挂载。
4.3 定期重建镜像
镜像是不可变的快照。基础镜像里的安全漏洞不会自动修复。定期用--pull重建镜像获取最新的安全补丁:
docker build --pull -t my-app:1.0 .五、总结
镜像优化的核心手段:
| 手段 | 效果 |
|---|---|
| 利用构建缓存 | 加快重复构建速度 |
| 多阶段构建 | 大幅减小镜像体积 |
| Alpine基础镜像 | 减小基础层体积 |
| 非root用户 | 提高安全性 |
| .dockerignore | 减小构建上下文,提高缓存稳定性 |
| 定期重建 | 获取安全补丁 |
记住一个原则:最终镜像里只放运行应用需要的东西。 编译器、构建工具、源代码、测试文件——这些都不应该出现在生产镜像里。
下一篇我们学习Docker Compose——当你的Agent服务需要Redis、数据库、Nginx等多个容器一起工作时,Docker Compose让你用一个YAML文件管理整个应用栈。